'''TMX file parsing.

This module defines the MakeLevel() commandlet used for building a pickled level
file from a TMX source file.
'''
from __future__ import print_function
from __future__ import unicode_literals
from __future__ import division

import argparse
import io
import zlib
import struct
import logging
from os import path
from xml.dom import minidom

from kharlia import core
from kharlia.engine.base import FSizeI, FVector2I, FVector3F, FRectF
from kharlia.engine.actor import (InitComponentClasses, GetComponentClasses,
                                  GetFullVarInfoMap)
from kharlia.engine.client import FTexture
from kharlia.engine.level import TileFromCoords, FTileset, FTileData, CLevel
from kharlia.engine.world import CWorld

__all__ = [
    'ConversionError',
    'ParsingError',
    'VerificationError',
    'BuildingError',
    'MakeLevel',
    ]

# Tile properties and their types to be parsed
TILE_PROPS = {
    'Blocking': int,
    'Shape': int
    }

LAYER_PROPS = {
    'Visible': bool,
    'Red': int,
    'Green': int,
    'Blue': int,
    'Alpha': int
    }

LEVEL_PROPS = {
    'Author': str,
    'CollisionLayer': int,
    }

# Undefined tiles in TilEd are replaced with the tile at this position,
# if another isn't specified in the level options
DEFAULT_TILE_POS = FVector2I(0, 0)

# TilEd flags for diagonal, vertical, and horizontal tile flipping.
TILE_FLIPD_FLAG = 0x20000000
TILE_FLIPV_FLAG = 0x40000000
TILE_FLIPH_FLAG = 0x80000000
TILE_FLIP_FLAGS = TILE_FLIPD_FLAG | TILE_FLIPV_FLAG | TILE_FLIPH_FLAG

class ConversionError(core.KharliaException):
    pass

class ParsingError(ConversionError):
    '''Raised for errors encountered while parsing data from the TMX XML document.'''

class VerificationError(ConversionError):
    '''Raised for errors encountered while verifying parsed data before build.'''

class BuildingError(ConversionError):
    '''Raised for errors encountered while building the the level object.'''

def MakeLevelDir(dirpath=None, recurse=0):
    pass

def MakeLevel(tmxpath=None):
    '''Commandlet to make a level from a TMX file created by TilED.
    
    If the path to the TMX file is not specified as a function argument, the first
    positional argument on the command line should be the path to the .tmx file.

    The level file is written in the same location as the TMX file and with the
    same name, except for a different extension of '.klv'.

    This function can only be used with an on-disk file, and not any file-like
    object because resource paths are relative to the file and need to be resolved
    during the make, which requires a valid file path.
    '''
    app = core.GetApp()

    if not tmxpath:
        parser = argparse.ArgumentParser(prefix_chars='-+', add_help=False)
        parser.add_argument('tmxpath', type=str, nargs=1)

        args, app.Args = parser.parse_known_args(app.Args)

        if len(args.tmxpath) == 0:
            print('No TMX path specified.')
            return

        tmxpath = args.tmxpath[0]

    if not isinstance(tmxpath, unicode):
        tmxpath = unicode(tmxpath)

    print('Making level from TMX file:', tmxpath)
    print('Reading TMX file...')
    try:
        with io.open(tmxpath, 'r') as f:
            doc = minidom.parse(f)
    except IOError as ex:
        print('Unable to read TMX file:', ex)
        return

    print('Parsing TMX file...')
    VerifyDoc(doc)
    info = ParseLevelInfo(doc)
    groups = ParseObjects(doc)
    layers = ParseTileLayers(doc)
    tilesets = ParseTilesets(doc, tmxpath)
    doc.unlink()
    del doc

    print('Importing dependent modules...')
    lwidth, lheight, lprops = info
    # This is where we import any modules that the level uses for implementing
    # scripts/components so that the component classes will be available when
    # building the actors.
    err = ImportModules(lprops)
    if err:
        print('Stopping due to import errors.')
        return

    print('Starting the engine...')
    ensvc = app.ServiceManager.GetServiceNoFail('kharlia.engine.main.CEngineService')
    if not ensvc.IsStarted:
        ensvc.Start()
    else:
        InitComponentClasses() # Init anything that was recently imported
    eng = ensvc.Engine
    resmgr = eng.ResourceManager
    
    print('Verifying data...')
    err = VerifyComponentTypes(groups)
    if err:
        print('Stopping due to invalid component classes.')
        return

    print('Matching layers to tilesets...')
    MatchLayersToTilesets(layers, tilesets)

    print('Encoding tiles...')
    err = False
    try:
        defpos, err = ParsePropertyValue(lprops['DefaultTile'], FVector2I)
    except KeyError:
        defpos = DEFAULT_TILE_POS
    if err:
        raise ValueError('invalid DefaultTile value')
    EncodeTiles(layers, info[0], info[1], TileFromCoords, defpos)
    EncodeTileProps(tilesets, TileFromCoords)

    print('Building level...')
    level = BuildLevel(tmxpath, info, resmgr)
    BuildTilesets(level, tilesets, tmxpath, None, resmgr)
    BuildLayers(level, layers, tilesets)
    BuildGroups(level, groups, resmgr)

    print('Writing level...')
    tmxbase, tmxext = path.splitext(tmxpath)
    newpath = tmxbase + '.klv'
    with io.open(newpath, 'wb') as f:
        #engine.GLogger = logging.getLogger('engine') # Temporary hack
        CWorld.SaveLevelPackage(level, f)

    print('Wrote level:', newpath)

    level.Clear()
    ensvc.Stop()

def VerifyDoc(doc):
    '''Verify that the TMX document is properly formed.'''
    el_doc = doc.documentElement
    if el_doc.tagName != 'map':
        raise ParsingError('missing map element')

def ParseLevelInfo(doc):
    '''Parse level width, height, and properties from the TMX document.
    
    Level width and height are specified in tile groups (64x64 tiles).

    Returns: [width:int, height:int, props:dict]
    '''
    el_doc = doc.documentElement

    # Verify the map size
    width = int(el_doc.getAttribute('width'))
    height = int(el_doc.getAttribute('height'))
    if ((width <= 0 or height <= 0 or
            (width // 64) > 0xFFFF or (height // 64) > 0xFFFF)):
        raise ParsingError('invalid map size')

    # Parse properties
    props = {}
    ELEMENT_NODE = minidom.Node.ELEMENT_NODE
    for el in el_doc.childNodes:
        if el.nodeType == ELEMENT_NODE and el.tagName == 'properties':
            for el2 in el.childNodes:
                if el2.nodeType != ELEMENT_NODE:
                    continue
                pname = el2.getAttribute('name')
                pvalue = el2.getAttribute('value')
                props[pname] = pvalue
                
    if len(props) < 1:
        raise ParsingError('level properties undefined')
    if 'Name' not in props:
        raise ParsingError('level name undefined')
    
    return [width // 64, height // 64, props]
    
def ParseObjects(doc):
    '''Parse objects from the TMX document.'''
    el_doc = doc.documentElement
    # Parse object groups
    # objectgroups: list<[groupname:uni, objects]>
    # objects: list<[name:uni, type:str, pos:(x:int, y:int), size:(width:int, height:int), props]>
    # props: dict<name:uni, value:uni>
    els_group = el_doc.getElementsByTagName('objectgroup')
    groups = []
    for el_group in els_group:
        gname = el_group.getAttribute('name')
        try:
            gindex = int(gname.rsplit(' ', 1)[-1])
        except ValueError:
            raise ParsingError('unspecified/unreadable group index for: ' + gname)
        # Parse objects
        els_object = el_group.getElementsByTagName('object')
        objects = []
        for el_obj in els_object:
            oname = el_obj.getAttribute('name')
            otype = str(el_obj.getAttribute('type'))   
            opos = (int(el_obj.getAttribute('x')),
                    int(el_obj.getAttribute('y')))
            osize = (int(el_obj.getAttribute('width')),
                     int(el_obj.getAttribute('height')))
            
            # Parse optional properties
            els_prop = el_obj.getElementsByTagName('property')
            oprops = {}
            for el_prop in els_prop:
                pname = el_prop.getAttribute('name')
                pvalue = el_prop.getAttribute('value')
                oprops[pname] = pvalue
                
            objects.append([oname, otype, opos, osize, oprops])
            
        groups.append([gname, gindex, objects])

    return groups

def ParseTileLayers(doc):
    '''Parse tile layers from the TMX document.'''
    el_doc = doc.documentElement
    # Parse tile layers
    els_layer = el_doc.getElementsByTagName('layer')
    layers = []
    for index, el_layer in enumerate(els_layer):
        name = el_layer.getAttribute('name')
        
        # Try getting index
        #try:
        #    layerindex = int(layername.rsplit(' ', 1)[-1])
        #except ValueError:
        #    raise RuntimeError('unspecified/unreadable layer index for: ' + layername)
            
        els_data = el_layer.getElementsByTagName('data')
        for el_data in els_data:
            assert el_data.getAttribute('encoding') == 'base64'
            assert el_data.getAttribute('compression') == 'zlib'
            elts_rawtiledata = el_data.childNodes
            assert len(elts_rawtiledata) == 1
            data = elts_rawtiledata[0].data
            data = data.decode('base64')
            data = zlib.decompress(data)
            # Unpack unsigned integers from string
            unpack_from = struct.unpack_from
            tiles = []
            for i in xrange(0, len(data), 4):
                t = unpack_from(b'<I', data, i)
                tiles.append(t[0])

        props = {}
        els_property = el_layer.getElementsByTagName('property')
        for el_property in els_property:
            pname = el_property.getAttribute('name')
            pvalue = el_property.getAttribute('value')
            props[pname] = pvalue

        visible = el_layer.getAttribute('visible') == '1' if \
                      el_layer.hasAttribute('visible') else True
        opacity = float(el_layer.getAttribute('opacity')) if \
                      el_layer.hasAttribute('opacity') else 1.0

        # name, index, visible, opacity, tiles, props
        layers.append([name, index, visible, opacity, tiles, props])

    return layers

def ParseTilesets(doc, tmxpath):
    '''Parse tilesets from the TMX document.

    Only a single tileset is currently supported per level.
    '''
    el_doc = doc.documentElement
    els_tileset = el_doc.getElementsByTagName('tileset')

    if len(els_tileset) == 0: # only one tileset supported for now
        raise ParsingError('no tileset defined')

    tilesets = []
    for el_tileset in els_tileset:
        offset = ((int(el_tileset.getAttribute('firstgid')) - 1) //
                     FTileset.PAGE_SIZE)
        if el_tileset.hasAttribute('source'):
            exsource = el_tileset.getAttribute('source')
            tmxhead, tmxtail = path.split(tmxpath)
            expath = path.join(tmxhead, exsource)
            try:
                with io.open(expath, 'r') as exf:
                    exdoc = minidom.parse(exf)
            except IOError:
                print('error: unable to load external tileset:', exsource)
                continue
            el_tileset = exdoc.documentElement
            assert el_tileset.tagName == 'tileset'
            print('loaded external tileset:', exsource)

        # Verify and get sizes
        assert el_tileset.getAttribute('tilewidth') == '16'
        assert el_tileset.getAttribute('tileheight') == '16'
        tsname = el_tileset.getAttribute('name')
        el_image = el_tileset.getElementsByTagName('image')[0]
        tswidth = int(el_image.getAttribute('width')) // 16
        tsheight = int(el_image.getAttribute('height')) // 16
        assert tsheight == 32
        isource = el_image.getAttribute('source')
        isize = (tswidth // 16) + 1
        
        # Parse tile properies
        props = []
        els_tile = el_tileset.getElementsByTagName('tile')
        for el_tile in els_tile:
            tid = int(el_tile.getAttribute('id'))
            tpdict = {}
            els_prop = el_tile.getElementsByTagName('property')
            for el_prop in els_prop:
                pname = el_prop.getAttribute('name')
                pvalue = el_prop.getAttribute('value')
                tpdict[pname] = pvalue 
            
            props.append((tid, tpdict))
            
        # name, width, height, offset, props, isource, isize
        tilesets.append([tsname, tswidth, tsheight, offset, props, isource, isize])

    return tilesets

def VerifyComponentTypes(groups):
    '''Verify that component types parsed from a TMX document exist.'''
    haserror = False
    compclasses = GetComponentClasses()
    for group in groups:
        for object in group[2]:
            ctypes = object[1].split(',')
            nctypes = []
            if len(ctypes) < 1:
                raise VerificationError('Unspecified type for actor: ' + object[1])
            for ctype in ctypes:
                actype = 'A' + ctype.strip()
                if actype not in compclasses:
                    haserror = True
                    print('Invalid component type name: ' + ctype)
                    continue
                nctypes.append(compclasses[actype])
            object[1] = nctypes
    return haserror

def MatchLayersToTilesets(layers, tilesets):
    default_tileset = None
    for tileset in tilesets:
        if tileset[0].lower() == 'default':
            default_tileset = tileset
    if not default_tileset:
        print('warning: no default tileset specified')
    for layer in layers:
        tileset_name = layer[5].get('#Tileset', None)
        tileset = None
        if not tileset_name:
            if not default_tileset:
                print('error: no default tileset available for layer:', layer[0])
            tileset = default_tileset
        else:
            for ts in tilesets:
                if ts[0] == tileset_name:
                    tileset = ts
                    break
        if not tileset:
            print('error: no tilset named \'{}\' for layer \'{}\''
                  .format(tileset_name, layer[0]))
            layer.append(None)
            continue
        layer.append(tileset)

def EncodeTiles(layers, width, height, converter, defpos):
    '''Encode the tiles specified in layers.

    Arguments:
    layers -- Result of ParseTileLayers()
    width -- Width of level in tile groups
    height -- Height of level in tile groups
    converter -- Callable converter function
    '''
    size_tiles = FSizeI(width * 64, height * 64)
    pos_zero = FVector2I(0, 0)
    PAGE_SIZE = FTileset.PAGE_SIZE
    def_tilecode = converter(defpos.X, defpos.Y)

    for layer in layers:
        tiles = layer[4]
        tileset = layer[-1]
        tswidth = tileset[1]
        offset = tileset[3]
        for i, tile in enumerate(tiles):
            tile &= ~TILE_FLIP_FLAGS # Top bits represent tile flips
            if tile == 0:
                tiles[i] = def_tilecode
            else:
                #assert offset == 0 or tile > offset * PAGE_SIZE
                #if offset != 0 and tile > offset * PAGE_SIZE:
                #    print('HIGH TILE:', tile)
                tile -= offset * PAGE_SIZE + 1
                tiles[i] = converter(tile % tswidth, tile // tswidth)
        layer[4] = td = FTileData(size_tiles)
        td.SetTilesFast(pos_zero, size_tiles, tiles)

def EncodeTileProps(tilesets, converter):
    '''Encode the tile ID for tile properties.

    Arguments:
    tilesets -- List of parsed tilsets.
    converter -- Callable to implement tile code conversion (engine.TileFromCoords).
    '''
    PAGE_SIZE = FTileset.PAGE_SIZE
    for tileset in tilesets:
        tswidth = tileset[1]
        if len(tileset[4]) > 0:
            newprops = []
            offset = tileset[3]
            for tid, tprops in tileset[4]:
                # This tile id is local to the tileset starting at 0 for the
                # first tile
                #print('PROP INITIAL TID:', tid)
                #tid &= ~TILE_FLIP_FLAGS
                #tid -= offset * PAGE_SIZE + 1
                tid = converter(tid % tswidth, tid // tswidth)
                #print('PROP TID:', tid)
                newprops.append((tid, tprops))
            tileset[4] = newprops

def ParsePropertyValue(value, vtype, resmgr=None):
    '''Parse unicode TMX property value based on type.

    Arguments:
    value -- Unicode value to be parsed
    vtype -- The desired type of the value
    resmgr -- Optional resource manager for parsing resource names

    Returns: value:?, error:bool
    '''
    assert type(value) is unicode

    if vtype in {int, float, str}:
        try:
            return vtype(value), False
        except ValueError:
            return None, True
    elif vtype is bool:
        value = value.strip().lower()
        if value == '1' or value.startswith('t'):
            return True, False
        elif value == '0' or value.startswith('f'):
            return False, False
        return None, True
    elif vtype is unicode:
        return value, False
    elif vtype is FVector2I:
        parts = value.split(',')
        if len(parts) != 2:
            return None, True
        try:
            n1 = int(parts[0])
            n2 = int(parts[1])
        except ValueError:
            return None, True
        return FVector2I(n1, n2), False
    elif vtype is FVector3F:
        parts = value.split(',')
        if len(parts) != 3:
            return None, True
        try:
            n1 = float(parts[0])
            n2 = float(parts[1])
            n3 = float(parts[2])
        except ValueError:
            return None, True
        return FVector3F(n1, n2, n3), False
    elif vtype is FRectF:
        parts = value.split(',')
        if len(parts) != 4:
            return None, True
        try:
            n = map(float, parts)
        except ValueError:
            return None, True
        return FRectF(*n), False
    elif vtype is FTexture:
        rname = str(value)
        if resmgr:
            r = resmgr.CreateResource(vtype, rname)
            return r, False
        else:
            return vtype(rname), False
    else:
        raise TypeError('unknown type:' + str(vtype))

def ParseRelPath(tmxpath, datapath, ipath):
    '''Parse a resource path relative to the TMX document.

    This should yield a path for use with CEngine.ResolvePath(), which is also
    a valid name for a texture resource.
    '''
    #print('TMXPATH:', tmxpath)
    #print('DATAPATH:', datapath)
    #print('IPATH:', ipath)
    tmxbase = path.split(tmxpath)[0]
    #print('TMXBASE:', tmxbase)
    aipath = path.normpath(path.join(tmxbase, ipath))
    aipath = path.abspath(aipath)
    #print('AIPATH:', aipath)
    atmxpath = path.abspath(tmxpath)
    #print('ATMXPATH:', atmxpath)
    common = path.commonprefix([aipath, atmxpath])
    #print('COMMON:', common)
    if len(common) > 0:
        ipath = aipath[len(common):]
        #print('TIPATH:', ipath)
    return ipath

def ImportModules(props):
    '''Import modules specified in level properties.

    Imports are specified with absolute module names separated by commas.

    This is necessary so all required component classes are loaded for the
    verification stage.
    '''
    err = False
    imports = props.get('Imports', None)
    if not imports:
        return
    parts = imports.split(',')
    for part in parts:
        part = part.strip()
        try:
            __import__(part, level=0)
            print('Imported module:', part)
        except ImportError:
            err = True
            print('Failed to import module:', part)
    return err

def BuildLevel(tmxpath, info, resmgr=None):
    width, height, props = info
    pbase, pfile = path.split(tmxpath)
    pfilename = path.splitext(pfile)[0]
    level = CLevel(None, pfilename, FSizeI(width, height))
    # Apply properties
    for pname, pvalue in props.iteritems():
        ptype = LEVEL_PROPS.get(pname, None)
        if not ptype:
            continue
        nvalue, err = ParsePropertyValue(pvalue, ptype, resmgr)
        if err:
            print('error: invalid property value \'{}\' for type \'{}\''
                  .format(pvalue, ptype))
            continue
        setattr(level, pname, nvalue)
        print('set level property {} to {}'.format(pname, pvalue))
    return level

def BuildTilesets(level, tilesets, tmxpath, datapath=None, resmgr=None):
    '''Build tilesets for the level. Only one tileset per level is supported.

    Arguments:
    level -- The CLevel object.
    tilesets -- List of tilesets parsed from TMX
    tmxpath -- Path used to open the TMX document
    resmgr -- Optional resource manager used for creating tileset textures
    '''
    for tileset in tilesets:
        name = tileset[0]
        assert type(name) is unicode
        offset = tileset[3]
        ipath = tileset[5]
        isize = tileset[6]
        ipath = ParseRelPath(tmxpath, datapath, ipath)
        assert type(ipath) is unicode
        tex, err = ParsePropertyValue(ipath, FTexture, resmgr)
        assert not err
        ts = FTileset(str(name), isize)
        ts.Texture = tex
        ts.Offset = offset
        tileset.append(ts)

def BuildLayers(level, layers, tilesets):
    '''Build tile layers for the level.

    Arguments:
    level -- The CLevel object
    layers -- List of layers parsed from the TMX, already encoded.
    tilesets -- Tilesets parsed from the level
    '''
    haserr = False
    pos_zero = FVector2I(0, 0)
    for index, layer in enumerate(layers):
        lev_layer = level.CreateLayer(index)
        lev_layer.Name = layer[0]
        level.SetTiles(index, pos_zero, layer[4])

        tileset = layer[-1]
        if tileset:
            assert isinstance(tileset[-1], FTileset)
            lev_layer.Tileset = tileset[-1]
            lev_layer.Visible = layer[2]
            lev_layer.Alpha = int(255 * layer[3])
        else:
            print('no tileset found for layer \'{}\''.format(layer[0]))
            haserr = True

        # Apply layer properties
        for pname, pvalue in layer[5].iteritems():
            if pname.startswith('#'):
                continue
            ptype = LAYER_PROPS.get(pname, None)
            if not ptype:
                print('warning: unknown layer property:', pname)
                continue
            nvalue, err = ParsePropertyValue(pvalue, ptype)
            if err:
                print('error: invalid value for layer property {}: {}'
                      .format(pname, pvalue))
                haserr = True
                continue
            setattr(lev_layer, pname, nvalue)

        # Apply tile properties
        for tid, tprops in tileset[4]:
            try:
                tinfo = lev_layer.Tileset.GetTileInfoByIndex(tid)
            except IndexError:
                print('Tile code not found:', tid)
                haserr = True
                continue
            for pname, pvalue in tprops.iteritems():
                ptype = TILE_PROPS.get(pname, None)
                if ptype:
                    nvalue, err = ParsePropertyValue(pvalue, ptype)
                    if not err:
                        setattr(tinfo, pname, nvalue)
                    else:
                        print('Failed to parse tile property'
                              ' {0} with value: {1}'.format(pname, pvalue))
    return haserr

def BuildGroups(level, groups, resmgr=None):
    '''Build actor groups.

    Arguments:
    level -- The level object
    groups -- Groups of objects parsed from TMX
    resmgr -- Optional resource manager used when parsing object properties
    '''
    alltypevi = {}
    compclasses = GetComponentClasses()
    for group in groups:
        gindex = group[1]
        for object in group[2]:
            #   objects: list of [name:uni, type:list, pos:tuple, size:tuple, props]
            name = object[0]
            if name == '':
                name = None
            actor = level.CreateActor(object[1], owner=None, name=name)

            trans = actor.Transform
            if trans:
                px = float(object[2][0]) / 16
                py = float(object[2][1]) / 16
                trans.Position = FVector3F(px, py, 0.0)

            col = actor.Collider
            if col:
                sw = float(object[3][0]) / 16
                sh = float(object[3][1]) / 16
                col.Bounds = FRectF(0.0, 0.0, sw, sh)

            for pkey, pvalue in object[4].iteritems():
                # Keys are in format TypeName.AttrName
                parts = pkey.split('.')
                if len(parts) != 2:
                    print('Invalid property; bad name format:', pkey)
                    continue
                ptypename, pname = parts

                # Retrieve component type
                ptype = compclasses.get('A' + ptypename, None)
                if not ptype:
                    print('Invalid property; unknown type:', pkey)
                    continue

                # Retrieve and cache varinfo for the component type
                varinfomap = alltypevi.get(ptype, None)
                if varinfomap is None:
                    varinfomap = GetFullVarInfoMap(ptype)
                    alltypevi[ptype] = varinfomap

                vi = varinfomap.get(pname, None)
                if not vi:
                    print('Invalid property; unknown attribute:', pkey)
                    continue

                comp = actor.GetComponent(ptype)
                if not comp:
                    print('Invalid property; no matching component:', pkey)
                    continue

                ptype = vi['type']
                nvalue, err = ParsePropertyValue(pvalue, ptype, resmgr)
                if err:
                    print('Invalid property; invalid value: type={0}, name={1}, value={2}'
                          .format(ptype.__name__, pkey, repr(pvalue)))
                    continue
                setattr(comp, pname, nvalue)
                #print('Set property {0} ({2}): {1}'.format(pname, nvalue, ptype.__name__))